require 'shellwords'

class Git
  class Error < StandardError; end

  class Base
    attr_reader :project, :working_dir, :remote

    def initialize(project: nil, working_dir: '.', remote: nil)
      @project = project
      @working_dir = working_dir
      @remote = remote || @project
      @release_tag_pattern = nil
      @release_tags_with_order = nil
      @release_tags2shas = nil
      @release_shas2tags = nil
    end

    def gcommit(commit)
      Commit.new(self, resolve_commit(commit))
    end

    def tag_names
      @tag_names ||= git_command('tag -l').split("\n")
    end

    def commit_exist?(commit)
      success = system("git -C #{working_dir.shellescape} rev-parse -q --verify #{commit.shellescape} >/dev/null")
      success && $?.exitstatus == 0
    end

    def remote_exist?(remote)
      git_command('remote').split.include?(remote)
    end

    def branch_exist?(pattern)
      !git_command('branch', ['--list', '-a', pattern]).empty?
    end

    def branch_exist_in_remote?(remote_url, branch)
      git_command("ls-remote -h #{remote_url} #{branch}").split.include?("refs/heads/#{branch}")
    end

    def kernel_branch?(branch)
      return false unless command_lines('show', "#{branch}:").include?('Makefile')
      return false unless git_command('show', "#{branch}:Makefile").include?('KERNELRELEASE')

      true
    rescue StandardError
      false
    end

    def release_tag_pattern
      @release_tag_pattern ||= Regexp.new "^#{Array(project_spec['release_tag_pattern']).join('$|^')}$"
    end

    def release_tags
      @release_tags ||= tag_names.select { |tag_name| release_tag_pattern.match(tag_name) }
    end

    def release_tags_with_order
      unless @release_tags_with_order
        tags = sort_tags(release_tag_pattern, release_tags)
        @release_tags_with_order = Hash[tags.map.with_index { |tag, i| [tag, -i] }]
      end

      @release_tags_with_order
    end

    def ordered_release_tags
      release_tags_with_order.keys
    end

    def ordered_official_release_tags
      release_tags_with_order.keys.select { |k| k =~ /^v[0-9]*\.[0-9]*(|\.[0-9]*)$/ }
    end

    def release_shas
      @release_shas ||= release_tags.map { |release_tag| git_command('rev-list', ['-1', release_tag]) }
    end

    def release_tags2shas
      unless @release_tags2shas
        tags = release_tags
        shas = release_shas

        @release_tags2shas = {}
        tags.each_with_index { |tag, i| @release_tags2shas[tag] = shas[i] }
      end

      @release_tags2shas
    end

    def release_shas2tags
      unless @release_shas2tags
        tags = release_tags
        shas = release_shas

        @release_shas2tags = {}
        shas.each_with_index { |sha, i| @release_shas2tags[sha] = tags[i] }
      end

      @release_shas2tags
    end

    def release_tag_order(tag)
      release_tags_with_order[tag]
    end

    def sort_commits(commits)
      scommits = commits.map(&:to_s)
      if scommits.size == 2
        r = git_command('rev-list', ['-n', '1', "^#{scommits[0]}", scommits[1]])
        scommits.reverse! if r.strip.empty?
      else
        r = git_command('rev-list', ['--no-walk', '--topo-order', '--reverse'] + scommits)
        scommits = r.split
      end

      scommits.map { |sc| gcommit sc }
    end

    def first_sha
      @first_sha ||= git_command('rev-list --reverse HEAD | head -1').chomp
    end

    def project_spec
      $remotes ||= load_remotes

      $remotes[@remote] || $remotes["internal-#{@remote}"]
    end

    def linux_last_release_tag_strategy(commit_sha)
      makefile = git_command("show #{commit_sha.shellescape}:Makefile 2>/dev/null")
      return nil unless makefile

      version = patch_level = sub_level = rc = nil

      makefile.each_line do |line|
        case line
        when /^#/
          next
        when /^VERSION\s*=\s*(\d+)/
          version = $1.to_i
        when /^PATCHLEVEL\s*=\s*(\d+)/
          patch_level = $1.to_i
        when /^SUBLEVEL\s*=\s*(\d+)/
          sub_level = $1.to_i
        when /^EXTRAVERSION\s*=\s*-rc(\d+)/
          rc = $1.to_i
        end
      end

      if version && version >= 2
        tag = "v#{version}.#{patch_level}"
        tag += ".#{sub_level}" if version == 2
        tag += "-rc#{rc}" if rc && rc.positive?
        [tag, false]
      else
        nil
      end
    rescue Error
      nil
    end

    def commits_tags
      return @commits_tags if @commits_tags

      @commits_tags = {}
      git_command('show-ref', ['--tags']).each_line do |line|
        commit, tag = line.split ' refs/tags/'
        @commits_tags[commit] = tag.chomp if tag
      end

      @commits_tags
    end

    def heads_branches
      return @heads_branches if @heads_branches

      @heads_branches = {}
      git_command('show-ref').each_line do |line|
        commit, branch = line.split ' refs/remotes/'
        @heads_branches[commit] = branch.chomp if branch
      end

      @heads_branches
    end

    def resolve_commit(commit)
      git_command("rev-parse #{commit.shellescape}").chomp
    end

    def git_command(command)
      full_command = "git -C #{working_dir.shellescape} #{command}"
      output = `#{full_command} 2>&1`
      raise Error, "Git command failed: #{full_command}" unless $?.success?

      output
    end

  end

  class Commit
    attr_reader :sha, :base

    def initialize(base, sha)
      @base = base
      @sha = sha
    end

    def tags
      @tags ||= base.git_command("tag --points-at #{sha.shellescape}").split("\n")
    end

    def tag
      @tag ||= release_tag || tags.first
    end

    def parent_shas
      @parent_shas ||= parents.map(&:sha)
    end

    def show(content)
      @base.command_lines('show', "#{sha}:#{content}")
    end

    def release_tag
      unless @release_tag
        release_tags_with_order = @base.release_tags_with_order
        @release_tag = tags.find { |tag| release_tags_with_order.include? tag }
      end
      @release_tag
    end

    #
    # if commit has a version tag, return it directly
    # otherwise checkout commit and get latest version from Makefile.
    #
    def last_release_tag
      return [release_tag, true] if release_tag

      if project == 'linux' && !@base.project_spec['use_customized_release_tag_pattern']
        @base.linux_last_release_tag_strategy(sha)
      else
        last_release_sha = @base.git_command("rev-list #{sha} | grep -m1 -Fx \"#{@base.release_shas.join("\n")}\"").chomp

        last_release_sha.empty? ? nil : [@base.release_shas2tags[last_release_sha], false]
      end
    end

    def base_rc_tag
      # rli9 FIXME: bad smell here to distinguish linux by case/when
      commit = case project
               when 'linux'
                 @base.gcommit("#{sha}~") if committer.name == 'Linus Torvalds'
               end

      commit ||= self

      tag, _is_exact_match = commit.last_release_tag
      tag
    end

    # v3.11     => v3.11
    # v3.11-rc1 => v3.10
    def last_official_release_tag
      tag, _is_exact_match = last_release_tag
      return tag unless tag =~ /-rc/

      order = @base.release_tag_order(tag)
      tag_with_order = @base.release_tags_with_order.find { |tag, o| o <= order && tag !~ /-rc/ }

      tag_with_order ? tag_with_order[0] : nil
    end

    # v3.11     => v3.10
    # v3.11-rc1 => v3.10
    def prev_official_release_tag
      tag, is_exact_match = last_release_tag

      order = @base.release_tag_order(tag)
      tag_with_order = @base.release_tags_with_order.find do |tag, o|
        next if o > order
        next if o == order && is_exact_match

        tag !~ /-rc/
      end

      tag_with_order ? tag_with_order[0] : nil
    end

    # v3.12-rc1 => v3.12
    # v3.12     => v3.13
    def next_official_release_tag
      tag = release_tag
      return nil unless tag

      order = @base.release_tag_order(tag)
      return nil unless order

      @base.release_tags_with_order.reverse_each do |tag, o|
        next if o <= order

        return tag unless tag =~ /-rc/
      end

      nil
    end

    def next_release_tag
      tag = release_tag
      return nil unless tag

      order = @base.release_tag_order(tag)
      @base.release_tags_with_order.reverse_each do |tag, o|
        next if o <= order

        return tag
      end

      nil
    end

    def version_tag
      tag, is_exact_match = last_release_tag

      tag += '+' if tag && !is_exact_match
      tag
    end

    RE_BY_CC = /(?:by|[Cc][Cc]):\s*([^<\r\n]+) <([^>\r\n]+@[^>\r\n]+)>\s*$/.freeze

    def by_cc
      m = message
      pos = 0
      res = []
      while (mat = RE_BY_CC.match(m, pos))
        res.push Git::Author.new("#{mat[1]} <#{mat[2]}> #{Time.now.to_i} ")
        pos = mat.end 0
      end
      res
    end

    def reachable_from?(branch)
      branch = @base.gcommit(branch)
      r = @base.git_command('rev-list', ['-n', '1', sha, "^#{branch.sha}"])
      r.strip.empty?
    end

    def merged_by
      base = base_rc_tag
      tags = @base.ordered_release_tags.reverse
      tags = tags.drop_while { |tag| tag != base }.drop(1)
      tags.find { |tag| reachable_from?(tag) }
    end

    def relative_commit_date
      @base.git_command("log -n1 --format=format:'%cr' #{sha}")
    end

    def prev_official_release
      @base.gcommit(prev_official_release_tag)
    end

    def last_release
      @base.gcommit(last_release_tag.first)
    end

    alias committed_release last_release

    def merged_release
      merged_by && @base.gcommit(merged_by)
    end

    def abbr
      tag || sha[0..9]
    end

    def files
      @base.git_command("diff-tree --no-commit-id --name-only -r #{sha}").split
    end

    def fixed?(branch)
      short_sha = sha[0..7]
      !@base.git_command("log --grep 'Fixes:' #{sha}..#{branch} | grep \"Fixes: #{short_sha}\"").empty?
    end

    def reverted?(branch)
      reverted_subject = "Revert \\\"#{subject.gsub(/(["\[\]])/, '\\\\\1')}\\\""
      !@base.git_command("log --format=%s #{sha}..#{branch} | grep -x -m1 \"#{reverted_subject}\"").empty?
    end

    def exist_in?(branch)
      # $ git merge-base --is-ancestor 071e7d275bd4abeb7d75844020b05bd77032ac62 origin/master
      # fatal: Not a valid commit name 071e7d275bd4abeb7d75844020b05bd77032ac62
      @base.git_command("merge-base --is-ancestor #{sha} #{branch} 2>/dev/null; echo $?").to_i.zero?
    end

  end

  def self.open(project: nil, working_dir: '.', remote: nil)
    Base.new(project: project, working_dir: working_dir, remote: remote)
  end
end
